[Перевод] Когда целый день программировал на Zig: впечатления Rust-энтузиаста

image

Я — большой фанат Rust, так как в этом языке предоставляется отличное инструментальное оснащение, и, когда я пишу на этом языке, я могу быть вполне уверен, что этот код будет работать надёжно. Но иногда Rust ненавистен. Чтобы написать код на Rust, требуется немало времени, а некоторые вещи реализовать достаточно сложно (да, async, это я о тебе).

В прошлом году мне не раз доводилось слышать о новом низкоуровневом языке программирования, он называется Zig. И вот, наконец, я нашёл время, чтобы опробовать его на практике. В этой статье я хочу рассказать, что мне понравилось и не понравилось Zig (который я рассматривал с точки зрения Rust-программиста и тех высоких стандартов, к которым я привык в Rust).

Что же такое Zig?


Zig характеризуется как »… универсальный язык программирования и инструментарий для поддержки надёжного, оптимального софта, рассчитанного на переиспользование». Довольно невыразительно звучит, да?

Вот «уникальные маркетинговые преимущества» Zig:

  • Никакого скрытого потока управления; вы сами всё контролируете
  • Никакого скрытого выделения памяти; все эти операции явные, причём, можно пользоваться разными стратегиями выделения памяти
  • Ни препроцессора, ни макросов. Непосредственно пишем на Zig код, который обрабатывается во время компиляции
  • Отлично организовано взаимодействие с C/C++; поддерживает кросс-компиляцию, может использоваться в качестве оперативной замены для C.

Язык Zig немного похож на Rust, так как в обоих этих языках делается акцент на производительности и безопасности. В них не применяется сборка мусора, LLVM применяется как машинный интерфейс компилятора, а также предоставляются возможности тестирования кода. Но в них применяется современный синтаксис, и предлагаются такие возможности как обработка ошибок и опции.

const std = @import("std");

/// Removes the specified prefix from the given string if it starts with it.
pub fn removePrefix(input: []const u8, prefix: []const u8) []const u8 {
    if (std.mem.startsWith(u8, input, prefix)) {
        return input[prefix.len..];
    }
    return input;
}


Пусть такой код и многое упрощает, мне нравится считать, что Zig относится к C так, как Rust относится к C++. Есть и такое мнение, что Zig — это наследник С. Эмпирический опыт подсказывает, что целесообразно использовать Zig в тех проектах, где вы ранее воспользовались бы C.

Хорошо ли это?


Обычно, чтобы изучить новый язык программирования, я стараюсь написать на нём несколько простых программ от начала и до конца. В данном случае я решил написать клиент для telnet (это старый сетевой протокол для удалённого доступа к терминалам). Это было непростое начинание, так как telnet гораздо сложнее, чем кажется. О нём можно написать отдельную статью.

На самом деле, на реализацию этого проекта мне потребовалось более одного дня, однако, прозанимавшись им целый день, я стал ощущать, как будто разобрался с основами Zig. Полный исходный код к проекту находится здесь: https://github.com/michidk/telnet-zig/

Что мне не нравится в Zig


В принципе, полюбить Zig довольно легко, так как мне совсем не нравится программировать на C. Поэтому давайте сначала остановимся на том, что меня в Zig не устраивает:

Сообщество и экосистема Zig невелики, для этого языка имеется не так много библиотек. Те, что имеются, пока не слишком хорошо проработаны. В этом Zig очень отличается от Rust, где найдётся хотя бы один очень популярный и качественно проработанный крейт на каждую задачу, решение которой вам хотелось бы вынести на уровень внешней библиотеки.

В Zig есть стандартная библиотека, которая почти настолько же минималистичная, как и стандартная библиотека Rust. В Zig она хоть и маленькая, но очень грамотно спроектированная. Многие методы в документации не описаны. Недокументированный код из 

std.io.Writer (https://ziglang.org/documentation/master/std/#A; std: io.Writer):

image

Полноценное сопоставление с шаблоном в Zig отсутствует. Но операторы switch достаточно мощные и, если собирать из них вложенные конструкции, то можно достичь примерно такого же эффекта, который в Rust достигается при помощи оператора match.

Типажи Rust (в других языках они называются «интерфейсы») обеспечивают полиморфизм — то есть, способность писать такой код, который оперирует разнотипными объектами. Эта мощная возможность очень пригодится, если требуется разрабатывать гибкие программные компоненты, рассчитанные на многократное использование. Но в Zig такая возможность отсутствует. В нём для этой цели полагаются на другие средства, в частности, на указатели функций или на полиморфизм во время компиляции. Такие компоненты могут быть не настолько интуитивно понятны и менее удобны в тех сценариях, которые принято обрабатывать при помощи интерфейсов или типажей.

Но, честно говоря, сложно было бы рассчитывать на иное, так как язык Zig довольно молод и только недавно начал набирать популярность.

Что мне нравится в Zig


Инструментарий, система сборки и тесты

Притом, что этот язык довольно молод, инструментарий у него отличный! Конечно, пока не такой продвинутый как в Rust с его cargo и clippy. Только в версии v0.11 выкатили официальный менеджер пакетов под названием Zon. Его можно использовать вместе с файлом build.zig (он похож на build.rs в Rust) для того, чтобы без особых хлопот загружать в наш проект библиотеки с GitHub (даже не хочу знать, сколько ценного времени на это тратилось раньше, когда приходилось работать с cmake и make).

$ zig build-exe hello.zig
$ ./hello
Hello, world!


В Zig, как и в Rust, встроены надёжные возможности тестирования. Тесты в Zig пишутся в виде специальных функций, благодаря чему их можно располагать бок о бок с тем кодом, который они проверяют. Для Zig характерна уникальная способность интерпретировать детали тестов во время компиляции. Кроме того, в Zig поддерживается кросс-компиляция при тестировании. Эта черта заслуживает особого упоминания, поскольку разработчики могут с лёгкостью протестировать код сразу под разные целевые архитектуры. Есть люди, которые даже пользуются Zig, чтобы протестировать свой код на C!

const std = @import("std");
const parseInt = std.fmt.parseInt;

// Unit testing
test "parse integers" {
    const ally = std.testing.allocator;

    var list = std.ArrayList(u32).init(ally);
    defer list.deinit();
...


Язык

Сам этот язык хорошо спроектирован и синтаксически очень похож на Rust. В обоих языках есть системы типов, в которых делается акцент на строгую статическую типизацию. Правда, в Zig и Rust отличается подход к обработке и выводу типов.

Обработка ошибок и опциональные значения

Как в Zig, так и в Rust продвигается явная обработка ошибок, хотя, отличаются механизмы, на которых она основана. В Rust для этого используются перечисления Result, тогда как в Zig — (глобальный) тип, которому как единое множество относятся все ошибки. Этот же тип отвечает и за распространение ошибок. Аналогично, в Rust с опциональными типами используется перечисление Option, тогда как в Zig действует модификатор типа (?T). В обоих языках предлагается современный синтаксический сахар, упрощающий обработку таких случаев (call()? и if let Some(value) = optional {} в Rust, try call() и if (optional) |value| {} в Zig). Поскольку в Rust обработка ошибок и работа с опциональными значениями реализуется при помощи стандартной библиотеки, пользователи могут сами расширять эти механизмы, и средства для этого достаточно мощные. Но мне нравится, как эти вопросы решаются в Zig, в котором эти штуки предоставляются как возможности языка. Притом, что такой подход хорошо вписывается во вселенную C, мне не нравится, что отсутствует прагматичный способ дать более широкий контекст для описания ошибки (да, конечно, никаких выделений памяти). Подобные проблемы можно решать при помощи таких библиотек как clap, которые реализуют диагностический механизм.

// Hello World in Zig
const std = @import("std");

pub fn main() anyerror!void {
      const stdout = std.io.getStdOut().writer();
      try stdout.print("Hello, {s}!\n", .{"world"});
}


Интероперабельность с C

В Zig обеспечивается первоклассная интероперабельность с C. Не приходится писать привязок, при работе с Zig просто можно воспользоваться встроенными функциями @cImport и @cInclude (выполняющими синтаксический разбор заголовочных файлов C), чтобы напрямую пользоваться кодом C.

Время компиляции

Можно писать на Zig такой код (без какого-либо специального синтаксиса макросов, как в случае с Rust), который интерпретируется во время компиляции, для этого применяется ключевое слово comptime. Так можно не только оптимизировать код, но и обеспечить рефлексию на уровне типов. Но операции динамического выделения памяти во время компиляции не разрешаются.

Типы

Типы в Zig, как и в Rust — это абстракции с нулевой стоимостью. Здесь есть примитивные типы, массивы, указатели, структуры (они подобны структурам из C, но могут включать методы), перечисления и объединения. Пользовательские типы реализуются на основе структур и дженериков, а именно — путём генерации параметризованных структур во время компиляции.

// std.io.Writer - это функция времени компиляции, возвращающая (обобщённую) структуру
pub fn Writer(
    comptime Context: type,
    comptime WriteError: type,
    comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
    return struct {
        context: Context,

        const Self = @This();
        pub const Error = WriteError;

        pub fn write(self: Self, bytes: []const u8) Error!usize {
            return writeFn(self.context, bytes);
        }
...


Выделение памяти

В Rust для управления памятью применяется автоматический механизм проверки заимствований, а в Zig, напротив, выбор сделан в пользу ручного управления памятью. Данное решение согласуется с философией Zig, в соответствии с которой программисту даётся полный контроль над программой, сокращаются издержки и случаи скрытого поведения.

Суть стратегии управления памятью в Zig заключается в использовании интерфейса Allocator. Через этот интерфейс разработчикам удобно в точности указывать, как нужно выделять, а затем забирать память. Разработчики могут выбирать из нескольких аллокаторов или реализовать собственные, подогнанные под конкретные нужды или цели оптимизации.

Это отлично, но на практике немного раздражает. Обычно аллокатор создаётся в самом начале кода приложения и присваивается переменной. Те методы, которым нужно выделять память, прямо в сигнатуре функции предусматривают аллокатор как один из параметров. Поэтому операции выделения памяти получаются прозрачными, но раздражают потому, что их то и дело приходится по многу раз передавать от функции к функции через всё приложение (или, как минимум, через те его части, где происходит выделение памяти).

Кросс-компиляция

Zig, как и Rust, нативно поддерживает кросс-компиляцию. Его интегрированный инструментарий упрощает компиляцию под разные архитектуры или операционные системы. Задать целевую архитектуру в Zig не сложнее, чем просто передать аргумент команде сборки:

# Сборка под Windows на Linux
zig build -Dtarget=x86_64-windows-gnu


Напротив, при использовании Rust требуется установить инструментарий целевой платформы при помощи rustup, а зачастую и вручную прописать конфигурацию линковщика для целевой платформы.

Заключение


Думаю, что Zig — хорошо спроектированный, интересный и мощный язык. Работать с ним может быть непросто, поскольку у него маленькая экосистема и не хватает документации. Но со временем эта ситуация должна улучшиться, особенно по мере того, как Zig станет набирать популярность. В Zig предоставляется современный синтаксис, отличная система типов, вы полностью контролируете, как выделяется память, а также суперсовременные языковые возможности.

В целом мне понравилось программировать на Zig, считаю, у него большой потенциал как у популярного языка для низкоуровневого программирования. Я думаю, что Zig (за несколько лет) может по-настоящему поменять правила игры в программировании встраиваемых систем, мне очень интересно посмотреть, как сложится его судьба в будущем.

© Habrahabr.ru